#!/usr/bin/env python
# -*- coding: UTF-8 -*-
'''
@Project ：V2 
@File    ：gas_qc_ga1.py
@IDE     ：PyCharm 
@Author  ：郭星
@Date    ：2025/9/23 10:56 
'''
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Gas Mixing -> QC Scheduling with Per-Bottle Multi-Device Testing (GA)
---------------------------------------------------------------------
场景要点
- 配气(MIX)：同配方(组分+浓度+family)可合批，亦可单做；并行配气机；序列相关setup(换型/吹扫)。
- 分析(QC)：每瓶样品选择一组设备覆盖所需组分：
    * 模式0：一次通过(同步并占用) —— 同一时间段占用多台设备完成检测；
    * 模式1：串行逐台 —— 按设备顺序依次检测；
  且仅在配气完成之后才能进入分析。
- 目标：加权(Cmax + 总迟期 + MIX setup + QC setup)。

染色体(编码)
- gene_split[N] : 订单是否强制单做(1=单做,0=可合批)
- qc_mode[N]    : 样品分析模式(0=一次通过,1=串行)
- mix_order[N]  : 订单顺序 → 引导配气批的顺序
- qc_order[N]   : 样品顺序 → 引导分析阶段的先后

解码(核心)
1) 根据 gene_split 合批，并按 mix_order 构造批次顺序；
2) 在并行配气机上排程(序列相关setup) → 得到每瓶 mix_end；
3) 根据 qc_order 逐个样品决策：
    - 用最小覆盖贪心选设备集合；
    - 若一次通过：同时在这组设备上占用同一时间段；
    - 若串行：按设备ID升序逐台，受上一台结束与设备就绪约束；
   → 得到每瓶 release_i (放行时刻)。

Author: You
"""

import random
import numpy as np
from dataclasses import dataclass
from typing import List, Dict, Set, Tuple, Optional

# 可选：绘图
try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except Exception:
    HAS_MPL = False


# ============================ 基础数据结构 ============================

@dataclass(frozen=True)
class Recipe:
    """配方键：同 key 的订单可合批"""
    family: str                                   # 比如 'N2' / 'Air' / 'Inert'
    components: Tuple[Tuple[str, float], ...]     # 有序 (组分, 浓度%)

    def key(self) -> Tuple[str, Tuple[Tuple[str, float], ...]]:
        return (self.family, self.components)

@dataclass
class Order:
    id: int
    recipe: Recipe
    qty: int           # 瓶数
    due: float         # 交期(小时)

@dataclass
class MixMachineConf:
    id: int
    name: str
    rate_qty_per_h: float
    setup_time_map: Dict[Tuple[Tuple[str,Tuple], Tuple[str,Tuple]], float]  # (from_key, to_key)->setup(h)

@dataclass
class QCDevice:
    id: int
    name: str
    caps: Set[str]       # 能测的组分集合
    base_time: float     # 固定时间
    per_sample_time: float  # 每瓶的附加时间(一次通过模式用到)
    setup_time: float    # 方法签名切换的setup

@dataclass
class Task:  # 用于甘特图
    stage: str            # "MIX" or "QC"
    machine_name: str
    start: float
    end: float
    label: str


# ============================ 车间参数 ============================

class Plant:
    def __init__(self,
                 mix_machines: List[MixMachineConf],
                 qc_devices: List[QCDevice],
                 max_mix_batch_qty: int = 600):
        self.mix_machines = mix_machines
        self.qc_devices = qc_devices
        self.max_mix_batch_qty = max_mix_batch_qty

    # MIX 加工时间：按批总瓶数/速率
    def mix_proc_time(self, total_qty: int, machine: MixMachineConf) -> float:
        return total_qty / machine.rate_qty_per_h

    # MIX 序列相关setup：此处用 family 级别映射(演示，可扩为完整recipe key矩阵)
    def mix_setup_time(self, prev_key, cur_key, machine: MixMachineConf) -> float:
        if prev_key is None or prev_key == cur_key:
            return 0.0
        return machine.setup_time_map.get((prev_key, cur_key), 0.4)

    # QC 方法签名：按“该设备上要测的子组分集”
    def qc_signature(self, req_comps: Set[str], dev: QCDevice) -> Tuple[str, Tuple[str, ...]]:
        sub = tuple(sorted(req_comps & dev.caps))
        return ("SIG", sub)

    # QC 一次通过时某设备对这瓶的处理时间：固定 + 每瓶项(若该设备承担≥1个组分)
    def qc_single_device_time(self, req_comps: Set[str], dev: QCDevice) -> float:
        """
        该设备对这瓶的检测时长 = 固定时间 + k * per_sample_time
        其中 k = 该设备实际承担的组分个数（req ∩ caps）
        """
        k = len(req_comps & dev.caps)
        return dev.base_time + k * dev.per_sample_time if k > 0 else 0.0


# ============================ 示例数据生成 ============================

def build_demo(seed=42):
    random.seed(seed); np.random.seed(seed)
    families = ["N2", "Inert", "Air"]

    # family级缺省setup矩阵(简化演示)
    def make_setup():
        d = {}
        for f1 in families:
            for f2 in families:
                base = 0.06 if f1 == f2 else (0.28 if {f1, f2} == {"N2","Inert"} else 0.55)
                d[((f1, ()), (f2, ()))] = base
        return d

    mix_machines = [
        MixMachineConf(0, "Mix1", rate_qty_per_h=180.0, setup_time_map=make_setup()),
        MixMachineConf(1, "Mix2", rate_qty_per_h=150.0, setup_time_map=make_setup()),
    ]
    qc_devices = [
        QCDevice(0, "E1", {"O2","CO2","CO"}, base_time=0.25, per_sample_time=0.10, setup_time=0.15),
        QCDevice(1, "E2", {"CH4","C2H6","CO2"}, base_time=0.30, per_sample_time=0.12, setup_time=0.20),
        QCDevice(2, "E3", {"H2","O2","N2"}, base_time=0.20, per_sample_time=0.08, setup_time=0.12),
    ]

    def mk_recipe(fam, comps: Dict[str, float]) -> Recipe:
        comps_t = tuple(sorted(((k, round(v,3)) for k,v in comps.items()), key=lambda x:x[0]))
        return Recipe(fam, comps_t)

    candidates = [
        mk_recipe("N2",    {"O2":2.0, "CO2":1.0}),
        mk_recipe("N2",    {"O2":1.0}),
        mk_recipe("N2",    {"CH4":1.5}),
        mk_recipe("Inert", {"H2":5.0}),
        mk_recipe("Air",   {"CO":0.5}),
        mk_recipe("N2",    {"CO2":3.0}),
        mk_recipe("Inert", {"H2":3.0, "CO2":1.0}),
        mk_recipe("N2",    {"CH4":0.8, "CO2":0.5}),
    ]

    orders: List[Order] = []
    for i in range(20):
        r = random.choice(candidates)
        qty = random.randint(60, 220)
        due = random.uniform(8.0, 30.0)
        orders.append(Order(i, r, qty, due))

    plant = Plant(mix_machines, qc_devices, max_mix_batch_qty=600)
    return plant, orders


# ============================ 解码器 ============================

class Decoder:
    """
    解码 = 建批 + MIX排程 + QC排程
    """

    def __init__(self, plant: Plant, orders: List[Order]):
        self.P = plant
        self.orders = orders
        self.N = len(orders)
        self.D = len(plant.qc_devices)

    # ---------- 1) 根据 gene_split + mix_order 生成 MIX 批次顺序 ----------
    def build_mix_batches(self, gene_split: np.ndarray, mix_order: List[int]) -> List[List[int]]:
        """
        返回：批列表(顺序已定)，每批是订单索引列表
        规则：
          - gene_split[i]==1 → 该订单单独成批；
          - gene_split[i]==0 → 与同配方订单合批(不超过批量上限)；
          - 批次顺序由 mix_order 引导：按 mix_order 的首次出现顺序排列 recipe 簇，
            同簇内部再按 mix_order 顺序贪心合批。
        """
        cap = self.P.max_mix_batch_qty

        # 同配方簇
        key2idx: Dict[Tuple, List[int]] = {}
        for i, o in enumerate(self.orders):
            key2idx.setdefault(o.recipe.key(), []).append(i)

        # recipe簇的排序：看“该簇中最早在 mix_order 出现的位置”
        pos = {i: mix_order.index(i) for i in range(self.N)}
        clusters = list(key2idx.items())
        clusters.sort(key=lambda kv: min(pos[i] for i in kv[1]))

        batches: List[List[int]] = []
        for key, idxs in clusters:
            # 强制单做者
            singles = [i for i in idxs if gene_split[i] == 1]
            for i in sorted(singles, key=lambda x: pos[x]):
                batches.append([i])

            # 允许合批者，按 mix_order 顺序
            rest = [i for i in idxs if gene_split[i] == 0]
            rest.sort(key=lambda x: pos[x])

            cur, cur_qty = [], 0
            for i in rest:
                q = self.orders[i].qty
                if cur_qty + q <= cap:
                    cur.append(i); cur_qty += q
                else:
                    if cur: batches.append(cur)
                    cur, cur_qty = [i], q
            if cur: batches.append(cur)

        return batches

    # ---------- 2) MIX 排程 ----------
    def schedule_mixing(self, batches: List[List[int]]) -> Tuple[Dict[int, float], List[Task], float]:
        """
        在并行配气机上排程批次。简单派工：选择“最早能完工”的机器。
        setup：按 family-level 矩阵(这里把key折叠成 (family,()))。
        返回：每瓶订单的 mix_end、任务列表、MIX总setup
        """
        M = len(self.P.mix_machines)
        ready = [0.0]*M
        last_key = [None]*M
        tasks: List[Task] = []
        total_setup = 0.0
        mix_end: Dict[int, float] = {}

        for b in batches:
            fam = self.orders[b[0]].recipe.family
            cur_key = (fam, ())
            total_qty = sum(self.orders[i].qty for i in b)
            best = None
            for m in range(M):
                mac = self.P.mix_machines[m]
                setup = self.P.mix_setup_time(last_key[m], cur_key, mac)
                st = ready[m] + setup
                pt = self.P.mix_proc_time(total_qty, mac)
                en = st + pt
                if (best is None) or (en < best[0]):
                    best = (en, m, st, pt, setup)
            en, m, st, pt, setup = best
            tasks.append(Task("MIX", self.P.mix_machines[m].name, st, en,
                              f"Batch({','.join('S'+str(i) for i in b)})"))
            ready[m] = en
            last_key[m] = cur_key
            total_setup += setup
            for i in b:
                mix_end[i] = en
        return mix_end, tasks, total_setup

    # ---------- 辅助：贪心最小覆盖选设备 ----------
    def greedy_min_cover(self, req: Set[str]) -> List[int]:
        devs = self.P.qc_devices
        remaining = set(req); picked=[]
        while remaining:
            best=None
            for d, dev in enumerate(devs):
                gain = len(dev.caps & remaining)
                if gain>0:
                    score = gain / (1e-6 + dev.base_time + dev.per_sample_time)
                    if (best is None) or (score > best[0]):
                        best = (score, d)
            if best is None: break
            _, d = best
            picked.append(d)
            remaining -= devs[d].caps
        return picked

    # ---------- 3) QC 排程（核心：一次通过 & 串行逐台） ----------
    def schedule_qc(self,
                    mix_end: Dict[int, float],
                    qc_mode: np.ndarray,  # 0=一次通过并占用；1=串行逐台
                    qc_order: List[int]  # 样品处理顺序（冲突时谁先排）
                    ) -> Tuple[float, float, List[Task], Dict[int, float]]:
        D = len(self.P.qc_devices)
        ready = [0.0] * D
        last_sig: List[Optional[Tuple[str, Tuple[str, ...]]]] = [None] * D
        total_setup = 0.0
        tasks: List[Task] = []
        release: Dict[int, float] = {}

        for i in qc_order:
            o = self.orders[i]
            req = {c for c, _ in o.recipe.components}
            # 设备集合：最小覆盖贪心
            devs_set = self.greedy_min_cover(req)
            if not devs_set:
                release[i] = mix_end[i]
                continue

            if qc_mode[i] == 0:
                # ---------- 一次通过：同时占用多台设备 ----------
                # 先计算每台设备上的setup和本瓶在该设备上的单独检测时长
                earliest = mix_end[i]
                per_info = []  # (d, sig, setup_time, pt)
                for d in devs_set:
                    dev = self.P.qc_devices[d]
                    sig = self.P.qc_signature(req, dev)
                    stp = 0.0 if last_sig[d] is None or last_sig[d] == sig else dev.setup_time
                    pt = self.P.qc_single_device_time(req, dev)
                    earliest = max(earliest, ready[d] + stp)  # 所有设备必须一起就绪 → 同一个开始时刻
                    per_info.append((d, sig, stp, pt))
                # 段长 = 各设备单独时长的最大值（不可拆、不可半途换）
                seg_len = max(pt for (_, _, _, pt) in per_info if pt > 0.0)
                # 如果其中某设备对这瓶没有实际任务（pt=0，通常不会被选进 devs_set），不会影响 seg_len
                st = earliest
                en = st + seg_len
                # 在所有设备上同时占用 [st,en]；并统一标注
                dev_names = [self.P.qc_devices[d].name for d, _, _, pt in per_info if pt > 0.0]
                label_txt = f"S{i}-1pass[{'+'.join(sorted(dev_names))}]"
                for d, sig, stp, pt in per_info:
                    if pt <= 0.0:  # 理论不进来；安全判断
                        continue
                    tasks.append(Task("QC", self.P.qc_devices[d].name, st, en, label_txt))
                    ready[d] = en
                    last_sig[d] = sig
                    total_setup += stp
                release[i] = en

            else:
                # ---------- 串行逐台 ----------
                cur_t = mix_end[i]
                for d in sorted(devs_set):
                    dev = self.P.qc_devices[d]
                    sig = self.P.qc_signature(req, dev)
                    stp = 0.0 if last_sig[d] is None or last_sig[d] == sig else dev.setup_time
                    st = max(cur_t, ready[d] + stp)
                    pt = self.P.qc_single_device_time(req, dev)
                    en = st + pt
                    tasks.append(Task("QC", dev.name, st, en, f"S{i}-serial"))
                    ready[d] = en
                    last_sig[d] = sig
                    total_setup += stp
                    cur_t = en
                release[i] = cur_t

        Cmax = max(release.values()) if release else 0.0
        return Cmax, total_setup, tasks, release


# ============================ GA 优化器 ============================

class GA:
    """
    染色体:
      gene_split[N] : 0/1   是否强制单做(配气合批开关)
      qc_mode[N]    : 0/1   分析模式(一次通过/串行)
      mix_order[N]  : 排序(影响配气批的顺序)
      qc_order[N]   : 排序(影响分析阶段处理先后)

    适应度:
      obj = w1*Cmax + w2*SumTardiness + w3*MixSetup + w4*QCSetup
    """
    def __init__(self, plant: Plant, orders: List[Order],
                 pop=80, gens=240, pc=0.9, pm=0.35, elite=10, seed=0):
        self.P = plant; self.orders = orders
        self.N = len(orders)
        self.pop = pop; self.gens = gens; self.pc = pc; self.pm = pm; self.elite = elite
        random.seed(seed); np.random.seed(seed)
        self.dec = Decoder(plant, orders)

    # ---- 编码：随机个体 ----
    def rand_ind(self):
        gene_split = np.random.randint(0,2,size=self.N,dtype=int)
        qc_mode    = np.random.randint(0,2,size=self.N,dtype=int)
        mix_order  = list(np.random.permutation(self.N))
        qc_order   = list(np.random.permutation(self.N))
        return (gene_split, qc_mode, mix_order, qc_order)

    # ---- 次序交叉OX ----
    def ox(self, a: List[int], b: List[int]) -> List[int]:
        n=len(a); i,j=sorted(np.random.choice(n,2,replace=False))
        child=[None]*n; child[i:j]=a[i:j]; pos=j
        for x in b:
            if x not in child:
                if pos>=n: pos=0
                child[pos]=x; pos+=1
        return child

    # ---- 交叉 ----
    def crossover(self, p1, p2):
        a1,a2,a3,a4 = p1; b1,b2,b3,b4 = p2
        # 基因1/2：单点
        cut = np.random.randint(1, self.N)
        c1_1 = np.concatenate([a1[:cut], b1[cut:]])
        c2_1 = np.concatenate([b1[:cut], a1[cut:]])
        cut2 = np.random.randint(1, self.N)
        c1_2 = np.concatenate([a2[:cut2], b2[cut2:]])
        c2_2 = np.concatenate([b2[:cut2], a2[cut2:]])
        # 基因3/4：OX
        c1_3 = self.ox(a3, b3)
        c2_3 = self.ox(b3, a3)
        c1_4 = self.ox(a4, b4)
        c2_4 = self.ox(b4, a4)
        return (c1_1, c1_2, c1_3, c1_4), (c2_1, c2_2, c2_3, c2_4)

    # ---- 变异 ----
    def mutate(self, ind, pm_split=0.2, pm_mode=0.2, pm_order=0.3):
        g1,g2,g3,g4 = ind
        if np.random.rand()<pm_split:
            i=np.random.randint(self.N); g1[i]=1-g1[i]
        if np.random.rand()<pm_mode:
            i=np.random.randint(self.N); g2[i]=1-g2[i]
        if np.random.rand()<pm_order:
            # 任选mix或qc顺序扰动
            which = random.choice([3,4])
            arr = g3 if which==3 else g4
            if len(arr)>=2:
                if np.random.rand()<0.5:
                    i,j=sorted(np.random.choice(len(arr),2,replace=False))
                    arr[i],arr[j]=arr[j],arr[i]
                else:
                    i=np.random.randint(len(arr))
                    val=arr.pop(i); j=np.random.randint(len(arr)+1); arr.insert(j,val)
        return (g1,g2,g3,g4)

    # ---- 适应度：完整解码 ----
    def fitness(self, ind):
        gene_split, qc_mode, mix_order, qc_order = ind
        # MIX
        batches = self.dec.build_mix_batches(gene_split, mix_order)
        mix_end, mix_tasks, mix_setup = self.dec.schedule_mixing(batches)
        # QC
        Cmax, qc_setup, qc_tasks, release = self.dec.schedule_qc(mix_end, qc_mode, qc_order)
        # KPI
        tard = sum(max(0.0, release[i]-self.orders[i].due) for i in range(self.N))
        kpis = dict(Cmax=Cmax, Tardiness=tard, MixSetup=mix_setup, QCSetup=qc_setup)
        # 目标权重（按需调）
        obj = 1.0*Cmax + 1.0*tard + 0.5*mix_setup + 0.3*qc_setup
        tasks = mix_tasks + qc_tasks
        return obj, kpis, tasks, release

    # ---- 锦标赛 ----
    def tournament(self, pool, k=2):
        idx = np.random.choice(len(pool), k, replace=False)
        best = min(idx, key=lambda i: pool[i][1])  # obj 越小越好
        return pool[best][0]

    # ---- 运行 ----
    def run(self, verbose=True):
        # init
        pop = [self.rand_ind() for _ in range(self.pop)]
        pool=[]
        for ind in pop:
            obj,kpis,tasks,rel = self.fitness(ind)
            pool.append((ind,obj,kpis,tasks,rel))
        best = min(pool, key=lambda x:x[1])
        hist=[best[1]]

        for g in range(1, self.gens+1):
            pool.sort(key=lambda x:x[1])
            elites = pool[:self.elite]
            new_pop=[e[0] for e in elites]
            while len(new_pop)<self.pop:
                p1 = self.tournament(pool,2)
                p2 = self.tournament(pool,2)
                c1,c2 = p1,p2
                if np.random.rand()<self.pc:
                    c1,c2 = self.crossover(p1,p2)
                if np.random.rand()<self.pm:
                    c1 = self.mutate(c1)
                if np.random.rand()<self.pm:
                    c2 = self.mutate(c2)
                new_pop.extend([c1,c2])
            pop = new_pop[:self.pop]

            # 评估
            pool=[]
            for ind in pop:
                obj,kpis,tasks,rel = self.fitness(ind)
                pool.append((ind,obj,kpis,tasks,rel))
            cur_best = min(pool, key=lambda x:x[1])
            if cur_best[1] < best[1]:
                best = cur_best
            hist.append(best[1])
            if verbose and g%20==0:
                print(f"[GA] Gen {g:4d}  Best={best[1]:.3f}")

        return best[0], best[1], best[2], hist, best[3], best[4]


# ============================ 可视化 ============================

def plot_convergence(hist):
    if not HAS_MPL: return
    plt.figure(figsize=(6,4))
    plt.plot(hist, lw=2)
    plt.xlabel("Generation"); plt.ylabel("Best-so-far Objective")
    plt.title("GA Convergence"); plt.grid(True, alpha=0.3)
    plt.tight_layout(); plt.show()
def plot_gantt(tasks: List[Task], title="Gantt (QC 1-pass = same start/end across devices)"):
    if not HAS_MPL or not tasks: return
    import re
    fig, ax = plt.subplots(figsize=(12, max(3, 0.6*len(set((t.stage,t.machine_name) for t in tasks))+2)))

    # 按行聚合
    rows = {}
    for t in tasks:
        rows.setdefault((t.stage, t.machine_name), []).append(t)
    keys = sorted(rows.keys(), key=lambda k:(k[0],k[1]))

    # 找到“一次通过同组”的任务：(样品id, start, end) 相同即同组
    def sid_and_mode(lbl: str):
        m = re.search(r"S(\d+)-1pass", lbl)
        return (int(m.group(1)) if m else None)

    groups = {}   # (sid, st, en) -> color
    from itertools import cycle
    color_cycle = cycle(plt.rcParams['axes.prop_cycle'].by_key()['color'])
    for t in tasks:
        sid = sid_and_mode(t.label)
        if sid is None:
            continue
        key = (sid, round(t.start,6), round(t.end,6))
        if key not in groups:
            groups[key] = next(color_cycle)

    # 画图
    ytick=[]; ylab=[]; y=0
    for key in keys:
        row = sorted(rows[key], key=lambda x:x.start)
        for t in row:
            sid = sid_and_mode(t.label)
            color = None
            label_txt = t.label
            if sid is not None:
                gkey = (sid, round(t.start,6), round(t.end,6))
                if gkey in groups:
                    color = groups[gkey]  # 同组同色
            ax.barh(y, t.end - t.start, left=t.start, height=0.8,
                    color=(color if color else None))
            ax.text((t.start+t.end)/2, y, label_txt,
                    ha='center', va='center',
                    color=('black' if color else 'black'), fontsize=6)
        ytick.append(y); ylab.append(f"{key[0]}-{key[1]}"); y+=1

    ax.set_yticks(ytick); ax.set_yticklabels(ylab)
    ax.set_xlabel("Time (h)"); ax.set_title(title)
    ax.grid(axis='x', alpha=0.3)
    plt.tight_layout(); plt.show()


# def plot_gantt(tasks: List[Task], title="Gantt"):
#     if not HAS_MPL or not tasks: return
#     rows = {}
#     for t in tasks:
#         key = (t.stage, t.machine_name)
#         rows.setdefault(key, []).append(t)
#     keys = sorted(rows.keys(), key=lambda k:(k[0],k[1]))
#     fig, ax = plt.subplots(figsize=(12, max(3, 0.6*len(keys)+2)))
#     ytick=[]; ylab=[]; y=0
#     for key in keys:
#         row = sorted(rows[key], key=lambda x:x.start)
#         for t in row:
#             ax.barh(y, t.end-t.start, left=t.start, height=0.8)
#             ax.text((t.start+t.end)/2, y, t.label, ha='center', va='center', color='white', fontsize=5)
#         ytick.append(y); ylab.append(f"{key[0]}-{key[1]}"); y+=1
#     ax.set_yticks(ytick); ax.set_yticklabels(ylab)
#     ax.set_xlabel("Time (h)"); ax.set_title(title)
#     ax.grid(axis='x', alpha=0.3); plt.tight_layout(); plt.show()


# ============================ 主程序 ============================

def main():
    plant, orders = build_demo(seed=7)
    ga = GA(plant, orders, pop=80, gens=240, pc=0.9, pm=0.35, elite=10, seed=1)
    best_ind, best_obj, kpis, hist, tasks, release = ga.run(verbose=True)

    print("\n== KPIs ==")
    for k,v in kpis.items():
        print(f"{k:>12s}: {v:.3f}")
    print(f"{'Objective':>12s}: {best_obj:.3f}")

    # 打印前若干订单的排程摘要
    gene_split, qc_mode, mix_order, qc_order = best_ind
    print("\nSample summary (first 10):")
    for i in range(min(10, len(orders))):
        print(f"S{i:02d}: split={gene_split[i]}  mode={'1-pass' if qc_mode[i]==0 else 'serial'}"
              f"  due={orders[i].due:.1f}h  release={release[i]:.2f}h")

    plot_convergence(hist)
    plot_gantt(tasks, title="Mix & QC Gantt (Per-bottle Multi-device Testing)")

if __name__ == "__main__":
    main()
